iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 23 篇

Day 23: Tooltip 不只是 Hover:補齊 UI Kit 裡的小型輔助元件

  • 分享至 

  • xImage
  •  

昨天 Day 22,我們第一次停下新增元件的腳步,整理了 CUI 的 Component Contract。

目前的基本規則變得比較清楚:

Semantic HTML
        ↓
Component API
        ↓
Variant / Size
        ↓
Native & ARIA State
        ↓
data-cui-* Contract
        ↓
Design Tokens

今天可以繼續加元件了。

不過這次先不做新的大型 Pattern,也不碰 Dialog 這種 Focus Management 大魔王。

來補幾個看起來很小、實際上在系統裡很常出現的輔助元件:

Tooltip
Separator
Visually Hidden

其中今天的主角是:

Tooltip

因為 Tooltip 看起來可能只是:

滑鼠移過去
↓
跳出一個小泡泡

但只要開始考慮 Accessibility,問題馬上冒出來:

沒有滑鼠怎麼辦?

Tab Focus 時會出現嗎?

Tooltip 裡的文字 Screen Reader 知道嗎?

Esc 能不能關閉?

滑鼠移到 Tooltip 本身時會不會消失?

Touch Device 怎麼辦?

重要資訊可以只放 Tooltip 裡嗎?

小小一顆,事情意外地不少。


1. 今天要完成什麼?

今天會補三個小型元件:

Tooltip
Separator
Visually Hidden

並整理它們各自的 Accessibility Responsibility:

Tooltip
→ 補充說明

Separator
→ 區隔內容

Visually Hidden
→ 視覺隱藏,但保留給 Assistive Technology

最後一樣補上 CUI Contract:

data-cui-slot

2. 先從 Tooltip 開始

Tooltip 最常出現在 Icon-only Button。

例如:

┌─────┐
│  ✎  │
└─────┘

滑鼠移上去:

      編輯
       ↓
┌──────────┐
│   編輯   │
└──────────┘
    ┌───┐
    │ ✎ │
    └───┘

這可以幫助看得到畫面的使用者理解:

這個 Icon 是做什麼的?

但第一個問題也來了:

Screen Reader 不會因為有 Tooltip,就自動知道這是一個「編輯」按鈕。


3. Tooltip 不是 Accessible Name

假設我們有:

<Tooltip>
  <TooltipTrigger>
    <Button size="icon-md">
      <PencilIcon />
    </Button>
  </TooltipTrigger>

  <TooltipContent>
    編輯
  </TooltipContent>
</Tooltip>

視覺使用者可能看得到:

✎
↓
編輯

但 Icon-only Button 本身仍然需要一個 Accessible Name。

例如:

<Button
  size="icon-md"
  aria-label="編輯申請"
>
  <PencilIcon aria-hidden="true" />
</Button>

這樣:

Button Accessible Name
→ 編輯申請

Tooltip
→ 編輯

兩者看起來有點重複,但責任不同。


4. Accessible Name 和 Tooltip 的責任

可以先這樣理解:

Accessible Name
→ 這個 Control 是什麼?

Tooltip
→ 給視覺使用者的補充提示

例如:

<Button aria-label="刪除申請">
  <TrashIcon aria-hidden="true" />
</Button>

Tooltip:

刪除

Screen Reader 使用者不需要先「打開 Tooltip」,才能知道 Button 是什麼。

所以不要把:

Accessible Name

的責任全部丟給 Tooltip。


5. Tooltip 不能只靠 Hover

如果 Tooltip 只有:

:hover

那 Keyboard User 怎麼辦?

使用者可能是:

Tab
↓
Icon Button Focus

卻永遠看不到 Tooltip。

所以 Accessible Tooltip 至少要考慮:

Pointer Hover
+
Keyboard Focus

也就是:

Hover → Open

Focus → Open

這也是為什麼今天不打算自己從零寫:

const [open, setOpen] = useState(false)

然後自己處理:

mouseenter
mouseleave
focus
blur
keydown

這些 Interaction 已經不是單純 CSS Bubble 了。


6. 使用 shadcn / Base UI 的 Tooltip

目前 CUI 是:

shadcn/ui
+
Base UI

所以先讓既有 Primitive 處理底層 Interaction。

安裝:

npx shadcn@latest add tooltip

產生:

src/components/ui/tooltip.tsx

通常會包含:

TooltipProvider
Tooltip
TooltipTrigger
TooltipContent

使用:

<Tooltip>
  <TooltipTrigger
    render={
      <Button
        variant="outline"
        size="icon-md"
        aria-label="編輯申請"
      />
    }
  >
    <PencilIcon aria-hidden="true" />
  </TooltipTrigger>

  <TooltipContent>
    編輯
  </TooltipContent>
</Tooltip>

實際 API 要以目前專案使用的 Base UI 版本為準。

CUI 不需要自己重新實作 Tooltip 的整套 Interaction。


7. Primitive 幫我們處理什麼?

這也是使用 Headless Primitive 的價值。

Tooltip 看起來很簡單,但底層其實需要處理:

Open / Close
Hover
Focus
Delay
Position
Escape
ARIA Relationship
Pointer Interaction

如果每個 Design System 都自己:

onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}

很容易只完成:

Mouse User ✓

其他使用方式全部漏掉。

所以 CUI 的工作不是:

重寫一套 Tooltip Engine。

而是:

在可靠 Primitive 上建立自己的 Styling、Contract 和 Usage Rules。


8. Tooltip 的 CUI Contract

跟前面的 Component 一樣,我們保留:

data-slot

再加入 CUI Public Contract。

例如:

data-cui-slot="tooltip-trigger"

以及:

data-cui-slot="tooltip-content"

如果 Root 本身沒有實際 DOM,就不一定需要硬塞:

data-cui-slot="tooltip"

昨天 Day 22 才整理過:

不要為了 Contract 而建立沒有用途的 Attribute。

所以 Contract 應該跟真正輸出的 DOM Structure 對應。


9. Tooltip 裡可以放什麼?

Tooltip 最適合:

短
簡單
非互動
補充性

例如:

編輯
刪除
複製連結
下載
更多選項

不適合:

一大段說明文字
表單
按鈕
連結
複雜操作

如果內容開始變成:

Tooltip
├── 說明
├── Link
└── Button

那它大概已經不是 Tooltip 了。

可能應該考慮:

Popover

10. Tooltip 不能藏必要資訊

這是今天最重要的規則之一。

假設表單:

身分證字號  ⓘ

所有輸入規則都只放在 Tooltip:

請輸入 10 碼身分證字號,
第一碼必須為英文字母……

這就有問題。

因為使用者可能:

沒發現 Tooltip
無法 Hover
使用 Touch Device
放大畫面後沒有注意 Icon

必要資訊應該直接存在頁面裡。

例如:

<FieldDescription>
  請輸入 10 碼身分證字號。
</FieldDescription>

Tooltip 可以補充:

為什麼需要這項資料?

而不是承擔:

沒有看 Tooltip 就無法完成任務

的資訊。


11. Tooltip 也不能代替 Label

例如:

[ 🔍 ]

然後只有 Hover 才顯示:

搜尋

Button 本身還是需要:

aria-label="搜尋"

同樣:

Input 沒有 Label
+
Tooltip 顯示「關鍵字」

也不能算有 Label。

所以:

Tooltip
≠ Label

Tooltip
≠ Accessible Name

Tooltip
≠ Error Message

Tooltip
≠ Required Instruction

Tooltip 是:

Supplementary Information


12. Tooltip 的 Keyboard Test

今天實作完成後,可以直接不用滑鼠。

按:

Tab

移到 Tooltip Trigger。

檢查:

Focus 看得到嗎?
Tooltip 有沒有出現?
Accessible Name 是否正確?

再按:

Escape

確認 Tooltip 能正確關閉,而 Focus 不會莫名消失。

接著:

Shift + Tab

離開 Trigger。

Tooltip 也應該正常消失。


13. Tooltip 的 Hover Test

滑鼠測試也不是只有:

移上去有沒有出現

還要注意:

Trigger → Tooltip

之間的移動。

如果使用者想把 Pointer 移到 Tooltip 文字附近,它不應該在 Pointer 剛離開 Trigger 的瞬間:

啪!
消失

尤其對:

Low Vision
Motor Impairment
Magnification User

可能造成使用困難。

這也是不自己手刻 Tooltip Interaction 的另一個原因。


14. Touch Device 呢?

Touch Device 沒有真正的:

Hover

所以更不能讓重要功能依賴:

「Hover 就看得到。」

Tooltip 在 Touch Device 上的行為可能依 Primitive 與 Interaction Design 不同。

但我們至少可以確保:

沒有 Tooltip
↓
Control 仍然可以理解、可以操作

這才是比較穩定的設計。


15. 接著補 Separator

第二顆元件簡單很多:

Separator

例如:

帳號設定
────────────
通知設定

安裝:

npx shadcn@latest add separator

Separator 可以有:

horizontal
vertical

例如:

<Separator />

或者:

<Separator orientation="vertical" />

16. Separator 不只是畫一條線

如果純粹只是 Decorative:

視覺上分隔

它不一定需要出現在 Accessibility Tree。

但如果這條 Separator 本身代表:

內容群組之間具有語意上的分隔

就可能保留 Separator Semantic。

因此要分清楚:

Visual Decoration

和:

Semantic Separation

不是每一條:

border-top

都需要:

role="separator"

17. Separator 的 CUI Contract

這顆相對單純:

data-cui-slot="separator"

如果 orientation 是 Public Styling Contract,也可以依 Primitive 實際輸出的:

data-orientation

處理。

昨天才整理過:

如果 Primitive / ARIA 已經提供狀態,不要急著複製成另一套 data-cui-*。

所以不一定需要:

data-cui-orientation="horizontal"

如果既有:

data-orientation="horizontal"

已經足夠。


18. 第三顆:Visually Hidden

最後這顆甚至可能:

完全看不到。

🤣

但 Accessibility UI Kit 很值得有。

Visually Hidden 的目的:

Visual
→ 看不到

Assistive Technology
→ 還是讀得到

我們前面其實已經使用過類似概念:

<span className="sr-only">
  申請紀錄載入中
</span>

以及:

<TableCaption className="sr-only">
  申請紀錄:包含姓名、申請項目、審核狀態與更新時間
</TableCaption>

這些其實就是:

Visually Hidden Content。


19. 為什麼不直接 display: none?

因為:

display: none;

通常代表:

Visual User
→ 看不到

Screen Reader
→ 也不讀

而 Visually Hidden 要的是:

Visual User
→ 看不到

Screen Reader
→ 可以讀

所以不能只是:

.hidden {
  display: none;
}

20. sr-only 已經可以用了,還需要 Component 嗎?

這是一個很合理的問題。

Tailwind 已經有:

<span className="sr-only">

那為什麼還要:

<VisuallyHidden>

?

其實兩種都可以。

如果只是偶爾:

<span className="sr-only">
  載入中
</span>

sr-only 已經非常清楚。

但如果 CUI 希望把:

Visually Hidden

正式定義成 Design System Pattern,就可以包成 Component。

例如:

<VisuallyHidden>
  開啟導覽選單
</VisuallyHidden>

這讓開發者不用記:

到底是 sr-only?
還是 hidden?
還是 opacity-0?

21. opacity: 0 也不是 Visually Hidden

這也很容易搞混。

opacity: 0;

只是:

看不見。

元素可能還是:

佔空間
可以 Focus
可以點擊
存在 Accessibility Tree

所以不要拿:

opacity: 0

當成通用的 Accessibility Hidden Solution。

不同的「隱藏」其實有不同目的:

display: none
→ 所有人都不要看到

aria-hidden="true"
→ Assistive Technology 不需要

sr-only / Visually Hidden
→ 視覺隱藏,但保留給 Assistive Technology

opacity: 0
→ 只是透明

這幾個不能互換。


22. Visually Hidden 最常出現在哪?

第一種就是:

Icon-only Button

例如:

<Button size="icon-md">
  <SearchIcon aria-hidden="true" />

  <VisuallyHidden>
    搜尋
  </VisuallyHidden>
</Button>

這樣 Button 的 Accessible Name 可以來自文字內容:

搜尋

而不是:

aria-label="搜尋"

兩種方式都有適用情境。


23. aria-label 還是 Visually Hidden?

例如:

<Button aria-label="關閉">
  <XIcon aria-hidden="true" />
</Button>

很乾淨。

另一種:

<Button>
  <XIcon aria-hidden="true" />

  <span className="sr-only">
    關閉
  </span>
</Button>

也可以。

兩者都能提供 Accessible Name。

目前 CUI 不需要規定:

全世界只能選其中一種。

但如果文字本身適合成為 DOM Content,我通常會偏好:

Real Text

因為它更容易:

翻譯
測試
搜尋
維護

而 aria-label 則很適合某些真的只有 Icon 的簡單 Control。


24. Tooltip + Visually Hidden 可以一起用

例如一顆 Icon Button:

<Tooltip>
  <TooltipTrigger
    render={
      <Button
        variant="outline"
        size="icon-md"
      />
    }
  >
    <PencilIcon aria-hidden="true" />

    <VisuallyHidden>
      編輯申請
    </VisuallyHidden>
  </TooltipTrigger>

  <TooltipContent>
    編輯
  </TooltipContent>
</Tooltip>

現在:

Visual User
→ Icon + Tooltip

Screen Reader
→ 編輯申請,按鈕

兩邊都不依賴對方才能理解 Control。


25. 但小心 Accessible Name 重複

如果已經:

aria-label="編輯申請"

又:

<VisuallyHidden>
  編輯申請
</VisuallyHidden>

就沒有必要兩套都加。

同樣也不要:

aria-label
+
aria-labelledby
+
Visually Hidden
+
Tooltip

全部塞上去,只因為:

Accessibility 越多越好!

不是這樣 😂

Accessibility Attribute 不是 Buff 疊層。

目標是:

用最簡單、正確的方式提供清楚語意。


26. 小元件也需要 Component Contract

今天三顆元件整理一下:

Tooltip
├── tooltip-trigger
└── tooltip-content

Separator
└── separator

Visually Hidden
└── visually-hidden

如果我們決定讓 Visually Hidden 成為正式 CUI Component,也可以:

data-cui-slot="visually-hidden"

但同樣要問:

Legacy CSS 真的需要這個 Hook 嗎?

如果只是:

一組固定的 visually-hidden CSS

它可能有價值。

如果沒有實際用途,也不用為了「每顆都有」硬加。

這就是昨天 Component Contract Audit 後開始建立的判斷方式。


27. Legacy 的 Tooltip 怎麼辦?

這題就開始有趣了。

React:

<Tooltip>
  ...
</Tooltip>

有 Base UI 幫忙處理 Behavior。

但 Legacy:

<button
  data-cui-slot="tooltip-trigger"
  aria-describedby="edit-tooltip"
>
  編輯
</button>

<div
  id="edit-tooltip"
  data-cui-slot="tooltip-content"
  role="tooltip"
>
  編輯申請
</div>

光有 HTML + CSS 還不夠。

還需要處理:

Open
Close
Hover
Focus
Escape
Position

也就是:

React
→ Base UI Behavior

Legacy
→ CUI Vanilla JS Adapter

這就是未來:

cui.js

可能開始有存在價值的地方。


28. 不是所有 Component 都需要 CDN JavaScript

例如:

Button
Badge
Alert
Table
Separator

大部分可能:

HTML + CSS

就夠了。

但:

Tooltip
Dialog
Select
Dropdown Menu

這些有 Interaction 的 Component:

HTML + CSS + Behavior

才可能需要:

cui.js

所以未來 CDN Build 很可能不是:

所有元件都靠 JavaScript

而是:

Static Components
→ CSS

Interactive Components
→ CSS + JS

這也會是 Day 27 做 CDN 時要真正解決的問題。


29. 今天做一次 Accessibility Test

Tooltip:

✓ Mouse Hover 可以開啟
✓ Keyboard Focus 可以開啟
✓ Escape 可以關閉
✓ Trigger 本身有 Accessible Name
✓ Icon 不製造多餘名稱
✓ 不把必要資訊只放 Tooltip

Separator:

✓ Decorative Separator 不製造多餘語意
✓ Semantic Separator 使用正確角色
✓ Orientation 正確

Visually Hidden:

✓ 視覺上不可見
✓ Assistive Technology 仍可取得內容
✓ 不使用 display: none 取代
✓ 不產生重複 Accessible Name

30. Keyboard Test

今天特別值得完全把滑鼠放開。

只使用:

Tab
Shift + Tab
Enter
Space
Escape

測 Tooltip。

流程:

Tab
↓
Focus Icon Button
↓
Tooltip 出現

Escape
↓
Tooltip 關閉

Shift + Tab
↓
Focus 離開

再確認:

Focus Indicator

一直都存在。

Tooltip 不應該把 Focus 搶走。


31. 今天真正學到的不是三顆元件

今天表面上加入:

Tooltip
Separator
Visually Hidden

但其實分別代表三種很不一樣的 Accessibility 問題:

Tooltip
→ Interaction + Supplementary Information

Separator
→ Visual vs Semantic Structure

Visually Hidden
→ Visual Tree vs Accessibility Tree

尤其 Tooltip 讓我開始碰到:

Focus
Hover
Escape
Pointer
Touch
Accessible Name

這些問題。

也就是:

我們開始從 Static Component,往真正的 Interactive Component 前進了。


32. 最後執行檢查

完成後:

npm run build
npm run lint
npm run check:contrast

再:

git status

如果今天建立獨立 Branch:

git switch -c feat/utility-components

完成後:

git add .
git diff --staged

Commit 可以:

git commit -m "feat: add accessible utility components"

Day 23 完成

今天補上的元件很小:

Tooltip
Separator
Visually Hidden

但它們讓 CUI 的 Accessibility 範圍又往外走了一點。

以前比較多是:

Semantic HTML
Color Contrast
Form Label
Validation
Table Structure
Data State

現在開始進入:

Hover
Focus
Escape
Accessible Name
Accessibility Tree
Interactive Behavior

而今天我最想留下的一句話是:

Tooltip 是補充資訊,不是資訊的唯一入口。

一個 Icon Button 即使 Tooltip 完全沒有出現:

Screen Reader
Keyboard
Touch

使用者還是應該知道:

這顆按鈕是做什麼的。

這才是 Tooltip 在 Accessible UI 裡比較健康的位置。


Next:Day 24

今天的 Tooltip 已經讓我們稍微碰到:

Open
Close
Focus
Escape

明天直接把難度往上拉。

來做:

Dialog

Dialog 看起來只是:

按 Button
↓
跳出 Modal

但 Accessibility 問題會一次全部出現:

Dialog 打開後 Focus 去哪?

Tab 能不能跑到背景?

Escape 要不要關閉?

關閉後 Focus 回哪?

Dialog 要怎麼取得 Accessible Name?

背景內容要怎麼處理?

Alert Dialog 又跟普通 Dialog 有什麼不同?

如果 Tooltip 是 Interactive Component 的入門,

那 Dialog 就是:

Focus Management 正式登場。

Day 24:

Dialog:打開一個視窗之後,Focus 到底該去哪?


上一篇
Day 22: 元件越做越多:整理 CUI Component Contract
下一篇
Day 24: Dialog:打開一個視窗之後,Focus 到底該去哪?
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言